iT邦幫忙

2024 iThome 鐵人賽

DAY 27
0
JavaScript

Signal API in Angular系列 第 27

Day 27 - Signal for state management

  • 分享至 

  • xImage
  •  

Signal 是建立儲存和管理 states 的好工具。對於一個簡單的應用程序,我們可以使用 Angular Signal 建立 store 來管理全域資料 (global data) 或本地資料 (local data)。 當應用程式擴充時,開發人員應考慮開源程式庫,例如 NGRXNGRX Signal StoreNGXSTanStack Store 等。

在這篇文章中,我使用 signal 建立一個 store 來追蹤我的收入和支出,計算總計和差異。

建立帳戶 Store

import { computed, Injectable, signal } from "@angular/core";
import { AccountRecords, InputItem } from "../types/account.type";
import { ItemType } from "../enums/account.enum";

@Injectable({
 providedIn: 'root'
})
export class AccountStore {
 #state = signal<AccountRecords>({
   incomes: [],
   expenses: [],
 });

  #totalIncomes = computed(() => this.#state().incomes.reduce((acc, item) =>
   acc + item.amount, 0));

 #totalExpenses = computed(() => this.#state().expenses.reduce((acc, item) => acc + item.amount, 0));

 summary = computed(() => ({
   incomes: this.#state().incomes,
   expenses: this.#state().expenses,
   totalIncomes: this.#totalIncomes(),
   totalExpenses: this.#totalExpenses(),
   hasMoneyLeft: this.#totalIncomes() > this.#totalExpenses(),
   surplus: this.#totalIncomes() - this.#totalExpenses()
 }));

 addItem({ type, date, amount, description }: InputItem) {
   const newItem = { date, amount, description };
   if (type === ItemType.INCOME) {
     this.#state.update((value) => ({
       incomes: [...value.incomes, newItem],
       expenses: value.expenses,
     }));
   } else if (type === ItemType.EXPENSE) {
     this.#state.update((value) => ({
       incomes: value.incomes,
       expenses: [...value.expenses, newItem],
     }));
   }
 }
}

AccountStore 服務 (service) 有一個私有變數 #state,用於將收入和支出記錄儲存在 signal 中。 該服務還具有多個計算訊號 (computed signal) 以從中獲取值。 #totalIncomes 計算收入總額,而#totalExpenses 計算支出總額。 #summary computed signal 回傳 incomesexpensestotalIncomestotalExpensessurplushasMoneyLeft。這是因為例子中的組件存取#summary 的屬性來顯示值。 store 公開 addItem 方法來更新 #state,而不是直接操作它。

建立一個表單組件用於資料輸入

@Component({
 selector: 'app-account-form',
 standalone: true,
 imports: [ReactiveFormsModule, FormsModule],
 template: `
   <form [formGroup]="form" style="margin-bottom: 1rem;" (ngSubmit)="formSubmitSub.next()">
     <div>
       <label for="date"><span>Date: </span></label>
       <input type="date" id="date" name="date" formControlName="date" />
     </div>
     <div>
       <label for="type"><span>Type: </span></label>
       <select id="type" name="type" formControlName="type">
           <option value="Income">Income</option>
           <option value="Expense">Expense</option>
       </select>
     </div>       
     <div>
       <label for="description"><span>Description: </span></label>
       <input id="description" name="description" formControlName="description" />
     </div>
     <div>
       <label for="amount"><span>Amount: </span></label>
       <input type="number" id="amount" name="amount" formControlName="amount" />
     </div>
     <button type="submit" [disabled]="this.form.invalid">Add an item</button>
   </form>
 `,
})
export default class AppAccountFormComponent {

 form = new FormGroup({
   type: new FormControl(ItemType.INCOME, { nonNullable: true, validators: [Validators.required] }),
   amount: new FormControl(0, { nonNullable: true, validators: [ Validators.required, Validators.min(0)] }),
   description: new FormControl('', { nonNullable: true, validators: [ Validators.required ]}),
   date: new FormControl(this.getCurrentDate(), { nonNullable: true, validators: [ Validators.required ]})
 });

 formSubmitSub = new Subject<void>();
 
 private getCurrentDate() {
   const today = new Date();
   const year = today.getFullYear();
   const month = (today.getMonth() + 1).toString().padStart(2, '0');
   const day = today.getDate().toString().padStart(2, '0');  

   return `${year}-${month}-${day}`;
 }

 submittedValues = outputFromObservable(this.form.events.pipe(
   filter((e) => e instanceof FormSubmittedEvent),
   filter((e) => e.source.valid),
   map(({ source }) => source.value),
 ));
}

AppAccountFormComponent 組件建構一個 reactive 表單,供使用者輸入收入或支出記錄。當使用者提交表單時, submittedValues output 會將表單資料傳送到父組件。

新增其他組件來顯示 store 的資料

import { Component, ChangeDetectionStrategy, input, HostAttributeToken, inject } from '@angular/core';
import { Item } from './types/account.type';

@Component({
 selector: 'app-account-list',
 standalone: true,
 template: `
   <h3 style="text-align: center;">{{ title }}</h3>
   @if (items().length) {
     <ol>
     @for (item of items(); track item) {
       <li>
         <p>Date: {{ item.date }}</p>
         <p>Description: {{ item.description }}</p>
         <p>Amount: {{ item.amount }}</p>
       </li>
     }
     </ol>
   } @else {
     <p>No item.</p>
   }
 `,
})
export default class AppAccountListComponent {
 items = input.required<Item[]>();
 title = inject(new HostAttributeToken('title'), { optional: true }) || 'Title'; 
}

AppAccountListComponent 組件從 store 接收收入或支出,並迭代陣列以顯示每筆記錄。

import { Component, ChangeDetectionStrategy, input } from '@angular/core';

@Component({
 selector: 'app-account-summary',
 standalone: true,
 template: `
   @let moneyLeft = summary().surplus;
   @let text = moneyLeft ? 'Surplus' : 'Deficit';
   <div>
     <p>Total income: {{ summary().totalIncomes }}</p>
     <p>Total expenses: {{ summary().totalExpenses }}</p>
     <p>{{ text }}: {{ moneyLeft }}
     <p>Has money left? {{ summary().hasMoneyLeft }}</p>
   </div>
 `,
})
export default class AppAccountSummaryComponent {
 summary = input.required<{
   totalIncomes: number;
   totalExpenses: number;
   hasMoneyLeft: boolean;
   surplus: number;
 }>();
}

AppAccountSummaryComponent 組件接收來自 store 的 summary,並顯示總數、差異以及是否還有錢可以花。

注入 store 並將 store 資料傳遞給組件

@Component({
 selector: 'app-account-wrapper',
 standalone: true,
 imports: [AppAccountFormComponent, AppAccountListComponent, AppAccountSummaryComponent],
 template: `
   <div class="photo-output-wrapper">
     <app-account-form class="form" />
     <h2>Balance Sheet</h2>
     <app-account-list title='Incomes' [items]="summary().incomes"  />
     <app-account-list title='Expenses' [items]="summary().expenses"  />
     <app-account-summary [summary]="summary()"
     />
   </div>
 `,
})
export default class AppAccountWrapperComponent {
 acountForm = viewChild.required(AppAccountFormComponent);
 store = inject(AccountStore);
 summary = this.store.summary;

 constructor() {
   effect((OnCleanUp) => {
    const sub = this.acountForm().submittedValues.subscribe((data) =>
       this.store.addItem(data as InputItem));

     OnCleanUp(() => sub.unsubscribe());
   });
 }
}

AppAccountWrapperComponent 注入 AccountStore 並將 summary computed signal 指派給summary 變數。 effect 訂閱 submittedValues 並將表單資料新增至 account store。當 store 的 signal 更新時,所有 computed signals 都會重新計算新值。

@let summary = store.summary();
<app-account-list title='Incomes' [items]="summary.incomes"  />
<app-account-list title='Expenses' [items]="summary.expenses"  />
<app-account-summary [summary]="summary" />

HTML 範本使用 let-語法 (let-syntax) 來暫時儲存 summary。然後,收入和支出傳遞到 AppAccountListComponent 組件的輸入。整個 summary 物件傳遞到 AppAccountSummaryComponent 組件以顯示總計、hasMoreMoney 標誌以及剩餘金額。

結論:

  • Signal 是 state management 的好工具。
  • 我們可以使用 Angular Signal 為小型應用程式建立我們的 store。當應用程式擴充時,應考慮 NGRXNGRX Signal Store 或其他函式庫。
  • 在此示範中,服務 (service) 封裝了 signals 和 computed signals,並提供了更新資料的方法。

另一種方法是使用 injectionToken 來提供 store,我明天將討論。

鐵人賽的第 27 天到此結束。

參考:


上一篇
Day 26 - 將 Decorators遷移到 input、queries 和 output 函數
下一篇
Day 28 - 使用 Facade Pattern 從 Signal 遷移到 State Management Library
系列文
Signal API in Angular39
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言